Skip to content
Closed
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
2 changes: 1 addition & 1 deletion documentation/docs/guides/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export GOOSE_MAX_TURNS=25
export GOOSE_MAX_TURNS=100

# Use multiple context files
export CONTEXT_FILE_NAMES='["CLAUDE.md", ".goosehints", "project_rules.txt"]'
export CONTEXT_FILE_NAMES='["CLAUDE.md", ".goosehints", ".cursorrules", "project_rules.txt"]'

# Set the ANSI theme for the session
export GOOSE_CLI_THEME=ansi
Expand Down
2 changes: 1 addition & 1 deletion documentation/docs/guides/recipes/session-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ You can turn your current Goose session into a reusable recipe that includes the

**Basic Usage** - Run once and exit (see [run options](/docs/guides/goose-cli-commands#run-options) and [recipe commands](/docs/guides/goose-cli-commands#recipe) for more):
```sh
# Using recipe file in current directory or GOOSE_RECIPE_PATH directories
# Using recipe file in current directory or `GOOSE_RECIPE_PATH` directories
goose run --recipe recipe.yaml

# Using full path
Expand Down
11 changes: 7 additions & 4 deletions documentation/docs/guides/using-goosehints.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ Goose supports two types of hint files:

You can use both global and local hints at the same time. When both exist, Goose will consider both your global preferences and project-specific requirements. If the instructions in your local hints file conflict with your global preferences, Goose will prioritize the local hints.

:::tip Custom Context File
You can [customize context file names](#custom-context-files) using the `CONTEXT_FILE_NAMES` environment variable.
:::tip Custom Context Files
You can use other agent rule files with Goose by using the [`CONTEXT_FILE_NAMES` environment variable](#custom-context-files).
:::

<Tabs groupId="interface">
Expand Down Expand Up @@ -143,12 +143,15 @@ Here's how it works:

### Configuration

Set the `CONTEXT_FILE_NAMES` environment variable to a JSON array of filenames. If not set, it defaults to `[".goosehints"]`.
Set the `CONTEXT_FILE_NAMES` environment variable to a JSON array of filenames. The default is `[".goosehints"]`.

```bash
# Single custom file
export CONTEXT_FILE_NAMES='["AGENTS.md"]'

# Multiple files (loaded in order)
# Project toolchain files
export CONTEXT_FILE_NAMES='[".cursorrules", "AGENTS.md"]'

# Multiple files
export CONTEXT_FILE_NAMES='["CLAUDE.md", ".goosehints", "project_rules.txt"]'
```
225 changes: 127 additions & 98 deletions ui/desktop/src/components/GooseMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useRef } from 'react';
import LinkPreview from './LinkPreview';
import ImagePreview from './ImagePreview';
import GooseResponseForm from './GooseResponseForm';
import { extractUrls } from '../utils/urlUtils';
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
import { formatMessageTimestamp } from '../utils/timeUtils';
import MarkdownContent from './MarkdownContent';
import ToolCallWithResponse from './ToolCallWithResponse';
import ToolCallChain from './ToolCallChain';
import { identifyConsecutiveToolCalls, shouldHideMessage, getChainForMessage } from '../utils/toolCallChaining';
import {
Message,
getTextContent,
Expand Down Expand Up @@ -35,39 +36,21 @@ interface GooseMessageProps {
export default function GooseMessage({
messageHistoryIndex,
message,
metadata,
messages,
metadata: _metadata,
toolCallNotifications,
append,
append: _append,
appendMessage,
isStreaming = false,
}: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
// Track which tool confirmations we've already handled to prevent infinite loops
const contentRef = useRef<HTMLDivElement>(null);
const handledToolConfirmations = useRef<Set<string>>(new Set());

// Extract text content from the message
let textContent = getTextContent(message);

// Utility to split Chain-of-Thought (CoT) from the visible assistant response.
// If the text contains a <think>...</think> block, everything inside is treated as the
// CoT and removed from the user-visible text.
const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => {
const regex = /<think>([\s\S]*?)<\/think>/i;
const match = text.match(regex);
if (!match) {
return { visibleText: text, cotText: null };
}

const cotRaw = match[1].trim();
const visible = text.replace(match[0], '').trim();
return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null };
};
// Extract image paths from the message content
const imagePaths = extractImagePaths(getTextContent(message));

const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent);

// Extract image paths from the visible part of the message (exclude CoT)
const imagePaths = extractImagePaths(textWithoutCot);
// Get text content without Chain of Thought
const textWithoutCot = getTextContent(message);

// Remove image paths from text for display
const displayText =
Expand All @@ -79,11 +62,53 @@ export default function GooseMessage({
// Get tool requests from the message
const toolRequests = getToolRequests(message);

// Get current message index
const messageIndex = messages.findIndex((msg) => msg.id === message.id);

// Enhanced chain detection that works during streaming
const toolCallChains = useMemo(() => {
// Always run chain detection, but handle streaming messages specially
const chains = identifyConsecutiveToolCalls(messages);

// If this message is streaming and has tool calls but no text,
// check if it should extend an existing chain
if (isStreaming && toolRequests.length > 0 && !displayText.trim()) {
// Look for an existing chain that this message could extend
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
if (previousMessage) {
const prevToolRequests = getToolRequests(previousMessage);

// If previous message has tool calls (with or without text), extend its chain
if (prevToolRequests.length > 0) {
// Find if previous message is part of a chain
const prevChain = chains.find(chain => chain.includes(messageIndex - 1));
if (prevChain) {
// Extend the existing chain to include this streaming message
const extendedChains = chains.map(chain =>
chain === prevChain ? [...chain, messageIndex] : chain
);
return extendedChains;
} else {
// Create a new chain with previous and current message
return [...chains, [messageIndex - 1, messageIndex]];
}
}
}
}

return chains;
}, [messages, isStreaming, messageIndex, toolRequests, displayText]);

// Check if this message should be hidden (part of chain but not first)
const shouldHide = shouldHideMessage(messageIndex, toolCallChains);

// Get the chain this message belongs to
const messageChain = getChainForMessage(messageIndex, toolCallChains);

// Extract URLs under a few conditions
// 1. The message is purely text
// 2. The link wasn't also present in the previous message
// 3. The message contains the explicit http:// or https:// protocol at the beginning
const messageIndex = messages?.findIndex((msg) => msg.id === message.id);
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : [];
const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : [];
Expand Down Expand Up @@ -132,7 +157,10 @@ export default function GooseMessage({
handledToolConfirmations.current.add(toolConfirmationContent.id);

appendMessage(
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
createToolErrorResponseMessage(
toolConfirmationContent.id,
'Tool execution was cancelled or interrupted.'
)
);
}
}
Expand All @@ -145,76 +173,96 @@ export default function GooseMessage({
appendMessage,
]);

return (
<div className="goose-message flex w-[90%] justify-start min-w-0">
<div className="flex flex-col w-full min-w-0">
{/* Chain-of-Thought (hidden by default) */}
{cotText && (
<details className="bg-bgSubtle border border-borderSubtle rounded p-2 mb-2">
<summary className="cursor-pointer text-sm text-textSubtle select-none">
Show thinking
</summary>
<div className="mt-2">
<MarkdownContent content={cotText} />
</div>
</details>
)}
// If this message should be hidden (part of chain but not first), don't render it
if (shouldHide) {
return null;
}

// Determine rendering logic based on chain membership and content
const isFirstInChain = messageChain && messageChain[0] === messageIndex;

{/* Visible assistant response */}
return (
<div className="group relative w-full">
<div className="flex flex-col items-start w-full">
{/* Regular text content - ALWAYS show if present */}
{displayText && (
<div className="flex flex-col group">
<div className={`goose-message-content py-2`}>
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
<div className="flex flex-col w-full">
<div ref={contentRef} className="w-full">
<MarkdownContent content={displayText} />
</div>

{/* Render images if any */}
{/* Image previews */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 mb-2">
<div className="mt-4">
{imagePaths.map((imagePath, index) => (
<ImagePreview key={index} src={imagePath} alt={`Image ${index + 1}`} />
<ImagePreview key={index} src={imagePath} />
))}
</div>
)}

{/* Only show timestamp and copy link when not streaming */}
<div className="relative flex justify-start">
{toolRequests.length === 0 && !isStreaming && (
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
)}
{displayText &&
message.content.every((content) => content.type === 'text') &&
!isStreaming && (
{/* URLs */}
{urls.length > 0 && (
<div className="mt-4">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
</div>
)}

{/* Show timestamp for text-only messages */}
{toolRequests.length === 0 && (
<div className="relative flex justify-start">
{!isStreaming && (
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
)}
{message.content.every((content) => content.type === 'text') && !isStreaming && (
<div className="absolute left-0 pt-1">
<MessageCopyLink text={displayText} contentRef={contentRef} />
</div>
)}
</div>
</div>
)}
</div>
)}

{/* Tool calls - either as chain or individual */}
{toolRequests.length > 0 && (
<div className="relative flex flex-col w-full">
{toolRequests.map((toolRequest) => (
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
<ToolCallWithResponse
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
/>
<>
{isFirstInChain ? (
// This is the first message in a chain - render the entire chain
<ToolCallChain
messages={messages}
chainIndices={messageChain}
toolCallNotifications={toolCallNotifications}
toolResponsesMap={toolResponsesMap}
messageHistoryIndex={messageHistoryIndex}
isStreaming={isStreaming}
/>
) : !messageChain ? (
// This message is not part of any chain - render individual tool calls
<div className="relative flex flex-col w-full">
{toolRequests.map((toolRequest) => (
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
<ToolCallWithResponse
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
/>
</div>
))}
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
</div>
))}
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
</div>
) : null}
</>
)}

{hasToolConfirmation && (
Expand All @@ -226,25 +274,6 @@ export default function GooseMessage({
/>
)}
</div>

{/* TODO(alexhancock): Re-enable link previews once styled well again */}
{/* eslint-disable-next-line no-constant-binary-expression */}
{false && urls.length > 0 && (
<div className="flex flex-wrap mt-[16px]">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
</div>
)}

{/* enable or disable prompts here */}
{/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */}
{/* eslint-disable-next-line no-constant-binary-expression */}
{false && metadata && (
<div className="flex mt-[16px]">
<GooseResponseForm message={displayText} metadata={metadata || null} append={append} />
</div>
)}
</div>
);
}
Loading
Loading