diff --git a/src/cli/App.tsx b/src/cli/App.tsx index baa2696..2134a0d 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -574,6 +574,14 @@ export default function App({ } }, [agentId]); + // Set terminal title to "{Agent Name} | Letta Code" + useEffect(() => { + const title = agentState?.name + ? `${agentState.name} | Letta Code` + : "Letta Code"; + process.stdout.write(`\x1b]0;${title}\x07`); + }, [agentState?.name]); + // Whether a stream is in flight (disables input) // Uses synced state to keep ref in sync for reliable async checks const [streaming, setStreaming, streamingRef] = useSyncedState(false); diff --git a/src/cli/components/EnterPlanModeDialog.tsx b/src/cli/components/EnterPlanModeDialog.tsx index cebe8b7..bc970bf 100644 --- a/src/cli/components/EnterPlanModeDialog.tsx +++ b/src/cli/components/EnterPlanModeDialog.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { colors } from "./colors"; type Props = { @@ -9,6 +10,7 @@ type Props = { export const EnterPlanModeDialog = memo(({ onApprove, onReject }: Props) => { const [selectedOption, setSelectedOption] = useState(0); + useProgressIndicator(); const options = [ { label: "Yes, enter plan mode", action: onApprove }, diff --git a/src/cli/components/InlineBashApproval.tsx b/src/cli/components/InlineBashApproval.tsx index 1700615..a2cf70f 100644 --- a/src/cli/components/InlineBashApproval.tsx +++ b/src/cli/components/InlineBashApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useMemo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; @@ -49,6 +50,7 @@ export const InlineBashApproval = memo( clear, } = useTextInputCursor(); const columns = useTerminalWidth(); + useProgressIndicator(); // Custom option index depends on whether "always" option is shown const customOptionIndex = allowPersistence ? 2 : 1; diff --git a/src/cli/components/InlineEnterPlanModeApproval.tsx b/src/cli/components/InlineEnterPlanModeApproval.tsx index eeb7459..ae47603 100644 --- a/src/cli/components/InlineEnterPlanModeApproval.tsx +++ b/src/cli/components/InlineEnterPlanModeApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; @@ -49,6 +50,7 @@ export const InlineEnterPlanModeApproval = memo( ({ onApprove, onReject, isFocused = true }: Props) => { const [selectedOption, setSelectedOption] = useState(0); const columns = useTerminalWidth(); + useProgressIndicator(); const options = [ { label: "Yes, enter plan mode", action: onApprove }, diff --git a/src/cli/components/InlineFileEditApproval.tsx b/src/cli/components/InlineFileEditApproval.tsx index b014784..78d9d9f 100644 --- a/src/cli/components/InlineFileEditApproval.tsx +++ b/src/cli/components/InlineFileEditApproval.tsx @@ -3,6 +3,7 @@ import { memo, useMemo, useState } from "react"; import type { AdvancedDiffSuccess } from "../helpers/diff"; import { parsePatchToAdvancedDiff } from "../helpers/diff"; import { parsePatchOperations } from "../helpers/formatArgsDisplay"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; @@ -165,6 +166,7 @@ export const InlineFileEditApproval = memo( clear, } = useTextInputCursor(); const columns = useTerminalWidth(); + useProgressIndicator(); // Custom option index depends on whether "always" option is shown const customOptionIndex = allowPersistence ? 2 : 1; diff --git a/src/cli/components/InlineGenericApproval.tsx b/src/cli/components/InlineGenericApproval.tsx index eb54312..e637c5e 100644 --- a/src/cli/components/InlineGenericApproval.tsx +++ b/src/cli/components/InlineGenericApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useMemo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; @@ -63,6 +64,7 @@ export const InlineGenericApproval = memo( clear, } = useTextInputCursor(); const columns = useTerminalWidth(); + useProgressIndicator(); // Custom option index depends on whether "always" option is shown const customOptionIndex = allowPersistence ? 2 : 1; diff --git a/src/cli/components/InlinePlanApproval.tsx b/src/cli/components/InlinePlanApproval.tsx index 9eca1c0..256a9ee 100644 --- a/src/cli/components/InlinePlanApproval.tsx +++ b/src/cli/components/InlinePlanApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useMemo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; @@ -41,6 +42,7 @@ export const InlinePlanApproval = memo( clear, } = useTextInputCursor(); const columns = useTerminalWidth(); + useProgressIndicator(); const customOptionIndex = 2; const maxOptionIndex = customOptionIndex; diff --git a/src/cli/components/InlineQuestionApproval.tsx b/src/cli/components/InlineQuestionApproval.tsx index ec5c881..b9cb87d 100644 --- a/src/cli/components/InlineQuestionApproval.tsx +++ b/src/cli/components/InlineQuestionApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { Fragment, memo, useMemo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; @@ -41,6 +42,7 @@ export const InlineQuestionApproval = memo( } = useTextInputCursor(); const [selectedMulti, setSelectedMulti] = useState>(new Set()); const columns = useTerminalWidth(); + useProgressIndicator(); const currentQuestion = questions[currentQuestionIndex]; diff --git a/src/cli/components/InlineTaskApproval.tsx b/src/cli/components/InlineTaskApproval.tsx index ae50213..37cdeec 100644 --- a/src/cli/components/InlineTaskApproval.tsx +++ b/src/cli/components/InlineTaskApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useMemo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; @@ -55,6 +56,7 @@ export const InlineTaskApproval = memo( clear, } = useTextInputCursor(); const columns = useTerminalWidth(); + useProgressIndicator(); // Custom option index depends on whether "always" option is shown const customOptionIndex = allowPersistence ? 2 : 1; diff --git a/src/cli/components/PlanModeDialog.tsx b/src/cli/components/PlanModeDialog.tsx index 5bdb737..d1334b2 100644 --- a/src/cli/components/PlanModeDialog.tsx +++ b/src/cli/components/PlanModeDialog.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput } from "ink"; import { memo, useState } from "react"; import { resolvePlaceholders } from "../helpers/pasteRegistry"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { colors } from "./colors"; import { MarkdownDisplay } from "./MarkdownDisplay"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; @@ -45,6 +46,7 @@ export const PlanModeDialog = memo( const [selectedOption, setSelectedOption] = useState(0); const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(""); + useProgressIndicator(); const options = [ { label: "Yes, and auto-accept edits", action: onApproveAndAcceptEdits }, diff --git a/src/cli/components/StaticPlanApproval.tsx b/src/cli/components/StaticPlanApproval.tsx index a2ff6fb..2e05791 100644 --- a/src/cli/components/StaticPlanApproval.tsx +++ b/src/cli/components/StaticPlanApproval.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { memo, useState } from "react"; +import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; @@ -37,6 +38,7 @@ export const StaticPlanApproval = memo( clear, } = useTextInputCursor(); const columns = useTerminalWidth(); + useProgressIndicator(); const customOptionIndex = 2; const maxOptionIndex = customOptionIndex; diff --git a/src/cli/hooks/useProgressIndicator.ts b/src/cli/hooks/useProgressIndicator.ts new file mode 100644 index 0000000..3b62f6e --- /dev/null +++ b/src/cli/hooks/useProgressIndicator.ts @@ -0,0 +1,45 @@ +import { useEffect } from "react"; + +/** + * Shows an indeterminate progress indicator in the terminal tab/taskbar + * while the component is mounted (useful for "waiting for user" states). + * + * Uses OSC 9;4 (ConEmu progress bar sequence) supported by: + * - iTerm2 + * - Windows Terminal + * - ConEmu + * - gnome-terminal (VTE) + * + * Format: ESC ] 9 ; 4 ; ; BEL + * States: + * 0 = hidden (clear) + * 1 = normal progress + * 2 = error state + * 3 = indeterminate (animated) + * 4 = warning state + */ + +// Show indeterminate progress (animated green bar) +const PROGRESS_INDETERMINATE = "\x1b]9;4;3;0\x07"; +// Clear/hide progress +const PROGRESS_CLEAR = "\x1b]9;4;0;0\x07"; + +/** + * Hook that shows an indeterminate progress indicator while mounted. + * Clears the indicator when the component unmounts. + * + * @param active - Whether the indicator should be shown (default: true) + */ +export function useProgressIndicator(active = true): void { + useEffect(() => { + if (!active) return; + + // Show indeterminate progress on mount + process.stdout.write(PROGRESS_INDETERMINATE); + + // Clear progress on unmount + return () => { + process.stdout.write(PROGRESS_CLEAR); + }; + }, [active]); +}