1- import {
2- CheckIcon ,
3- ChevronDownIcon ,
4- ChevronRightIcon ,
5- CircleXIcon ,
6- TerminalIcon ,
7- } from "lucide-react" ;
8- import { memo , useState } from "react" ;
1+ import { CheckIcon , ChevronDownIcon , ChevronUpIcon , CircleXIcon , TerminalIcon } from "lucide-react" ;
2+ import { memo , useLayoutEffect , useMemo , useRef , useState } from "react" ;
93import { cn } from "~/lib/utils" ;
4+ import { Tooltip , TooltipPopup , TooltipTrigger } from "~/components/ui/tooltip" ;
105import type { WorkLogEntry } from "../../session-logic" ;
116
127interface CommandExecutionCardProps {
@@ -16,13 +11,36 @@ interface CommandExecutionCardProps {
1611
1712type CommandStatus = "running" | "error" | "success" ;
1813
14+ const PREVIEW_MAX_HEIGHT = "120px" ;
15+
1916function deriveCommandStatus ( entry : WorkLogEntry , isLive : boolean ) : CommandStatus {
2017 if ( isLive && ! entry . detail && entry . exitCode === undefined ) return "running" ;
2118 if ( entry . tone === "error" || ( entry . exitCode !== undefined && entry . exitCode !== 0 ) )
2219 return "error" ;
2320 return "success" ;
2421}
2522
23+ const DETAIL_COMMAND_PREFIX_RE = / ^ (?: B a s h | S h e l l | S h ) : \s * / i;
24+
25+ function deriveCommandAndOutput ( entry : WorkLogEntry ) : {
26+ displayCommand : string | null ;
27+ output : string | null ;
28+ } {
29+ if ( entry . command ) {
30+ return { displayCommand : entry . command , output : entry . detail ?? null } ;
31+ }
32+ if ( entry . detail ) {
33+ const firstNewline = entry . detail . indexOf ( "\n" ) ;
34+ const firstLine = firstNewline === - 1 ? entry . detail : entry . detail . slice ( 0 , firstNewline ) ;
35+ if ( DETAIL_COMMAND_PREFIX_RE . test ( firstLine ) ) {
36+ const cmd = firstLine . replace ( DETAIL_COMMAND_PREFIX_RE , "" ) . trim ( ) ;
37+ const rest = firstNewline === - 1 ? null : entry . detail . slice ( firstNewline + 1 ) . trim ( ) || null ;
38+ return { displayCommand : cmd || null , output : rest } ;
39+ }
40+ }
41+ return { displayCommand : null , output : entry . detail ?? null } ;
42+ }
43+
2644const STATUS_ACCENT : Record < CommandStatus , string > = {
2745 running : "border-l-amber-400/40" ,
2846 error : "border-l-rose-400/40" ,
@@ -63,11 +81,20 @@ export const CommandExecutionCard = memo(function CommandExecutionCard(
6381) {
6482 const { entry, isLive } = props ;
6583 const [ expanded , setExpanded ] = useState ( false ) ;
84+ const [ previewOverflows , setPreviewOverflows ] = useState ( false ) ;
85+ const previewRef = useRef < HTMLDivElement > ( null ) ;
6686
6787 const status = deriveCommandStatus ( entry , isLive ) ;
68- const hasOutput = ! ! entry . detail ;
88+ const { displayCommand , output } = useMemo ( ( ) => deriveCommandAndOutput ( entry ) , [ entry ] ) ;
6989
70- const ToggleIcon = expanded ? ChevronDownIcon : ChevronRightIcon ;
90+ useLayoutEffect ( ( ) => {
91+ const el = previewRef . current ;
92+ if ( ! el || expanded ) return ;
93+ setPreviewOverflows ( el . scrollHeight > el . clientHeight + 1 ) ;
94+ } , [ expanded , output ] ) ;
95+
96+ const hasMoreContent = expanded || previewOverflows ;
97+ const ExpandIcon = expanded ? ChevronUpIcon : ChevronDownIcon ;
7198
7299 return (
73100 < div
@@ -77,40 +104,61 @@ export const CommandExecutionCard = memo(function CommandExecutionCard(
77104 STATUS_ACCENT [ status ] ,
78105 ) }
79106 >
80- < button
81- type = "button"
82- onClick = { ( ) => setExpanded ( ( prev ) => ! prev ) }
83- className = "flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-100 hover:bg-muted/20"
84- >
85- < ToggleIcon className = "size-3 shrink-0 text-muted-foreground/50" />
86- < TerminalIcon className = "size-3.5 shrink-0 text-muted-foreground/60" />
87- < span className = "text-[11px] font-medium text-muted-foreground/60" > Shell</ span >
88- < span className = "min-w-0 flex-1" />
89- < CommandStatusBadge entry = { entry } status = { status } />
90- </ button >
91-
92- < div className = "border-t border-border/20 px-3 py-1.5" >
93- { entry . command ? (
94- < p className = "font-mono text-[11px] leading-5 text-foreground/75" >
95- < span className = "text-muted-foreground/50" > $ </ span >
96- { entry . command }
97- </ p >
98- ) : (
99- < p className = "text-[11px] leading-5 text-muted-foreground/60" > { entry . label } </ p >
100- ) }
101- </ div >
102-
103- { expanded && (
104- < div className = "border-t border-border/20 px-3 py-2" >
105- { hasOutput ? (
106- < pre className = "max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-4 text-muted-foreground/55" >
107- { entry . detail }
108- </ pre >
107+ < Tooltip >
108+ < TooltipTrigger render = { < div className = "flex items-center gap-2 px-3 py-1.5" /> } >
109+ < TerminalIcon className = "size-3.5 shrink-0 text-muted-foreground/60" />
110+ { displayCommand ? (
111+ < span className = "min-w-0 flex-1 truncate font-mono text-[11px] text-foreground/80" >
112+ { displayCommand }
113+ </ span >
109114 ) : (
110- < p className = "text-[10px] italic text-muted-foreground/35" > No output</ p >
115+ < span className = "min-w-0 flex-1 text-[11px] text-muted-foreground/60" >
116+ { entry . label }
117+ </ span >
111118 ) }
119+ < CommandStatusBadge entry = { entry } status = { status } />
120+ </ TooltipTrigger >
121+ { displayCommand && (
122+ < TooltipPopup side = "top" className = "max-w-lg" >
123+ < p className = "break-all font-mono text-xs" > { displayCommand } </ p >
124+ </ TooltipPopup >
125+ ) }
126+ </ Tooltip >
127+
128+ { output && ! expanded && (
129+ < div
130+ ref = { previewRef }
131+ className = "relative overflow-hidden border-t border-border/20"
132+ style = { { maxHeight : PREVIEW_MAX_HEIGHT } }
133+ >
134+ < pre className = "whitespace-pre-wrap break-words px-3 py-1.5 font-mono text-[10px] leading-4 text-muted-foreground/55" >
135+ { output }
136+ </ pre >
137+ { previewOverflows && (
138+ < div className = "pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-card/90 to-transparent" />
139+ ) }
140+ </ div >
141+ ) }
142+
143+ { output && expanded && (
144+ < div className = "border-t border-border/20" >
145+ < pre className = "max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words px-3 py-1.5 font-mono text-[10px] leading-4 text-muted-foreground/55" >
146+ { output }
147+ </ pre >
112148 </ div >
113149 ) }
150+
151+ { hasMoreContent && (
152+ < button
153+ type = "button"
154+ data-scroll-anchor-ignore
155+ className = "flex w-full items-center justify-center gap-1.5 border-t border-border/30 py-1.5 text-[10px] text-muted-foreground/50 transition-colors hover:bg-muted/20 hover:text-muted-foreground/70"
156+ onClick = { ( ) => setExpanded ( ( prev ) => ! prev ) }
157+ >
158+ < ExpandIcon className = "size-3" />
159+ < span > { expanded ? "Hide output" : "Show full output" } </ span >
160+ </ button >
161+ ) }
114162 </ div >
115163 ) ;
116164} ) ;
0 commit comments