11import * as React from 'react' ;
2- import { BaseText , Box , Text } from '@anthropic/ink' ;
2+ import { BaseText , Box , Text , useTerminalSize } from '@anthropic/ink' ;
33import { useKeybindings } from '../../keybindings/useKeybinding.js' ;
44import { type EffortValue , getDisplayedEffortLevel , getEffortEnvOverride } from '../../utils/effort.js' ;
55import {
@@ -22,30 +22,70 @@ import {
2222 applyOverlaysToCells ,
2323 cellsToSegments ,
2424 computeRippleCells ,
25+ fadeCells ,
26+ getHueShiftAtTime ,
27+ rotateHue ,
2528} from './rippleAnimation.js' ;
2629
27- // 每档固定宽度,Ink Box 自动对齐。PANEL_WIDTH = SEGMENT * 6。
28- const SEGMENT = 12 ;
29- const PANEL_WIDTH = SEGMENT * PANEL_POSITIONS . length ;
30+ /**
31+ * 每档最小宽度(足够装下 'ultracode' 9 字符 + 居中留白)。
32+ * 当终端窄时使用此值,保证最低可读性。
33+ */
34+ const MIN_SEGMENT = 12 ;
35+
3036const SUBLABEL_ULTRACODE = 'xhigh + workflows' ;
3137
3238// 颜色:与项目主题对齐(suggestion=Medium blue #5769F7)。
3339const COLOR_LABEL_SELECTED = '#5769F7' ; // 选中档位(suggestion)
34- const COLOR_LABEL_DEFAULT = '#8a8a8a ' ; // 未选中档位(subtle gray )
40+ const COLOR_LABEL_DEFAULT = '#7a8eff ' ; // 未选中档位(淡紫蓝,与波纹背景协调 )
3541const COLOR_OVERLAY = '#5769F7' ; // Faster / Smarter / ▲ 等 overlay 文字
3642
37- // 波纹震源坐标(相对波纹区域坐标系,y=0 是档位名行) 。
38- // ultracode 字符在 SEGMENT*5=60 起始段内居中(9 字符 in 12 列 → 偏移 1.5 → 1),
39- // 中心列 ≈ 60 + 1 + 4 = 65。
40- const RIPPLE_SOURCE_X = SEGMENT * 5 + 5 ;
43+ // 淡入淡出每帧步长:60ms 间隔下 5 帧达到目标 ≈ 300ms 动画时长 。
44+ const FADE_STEP = 0.2 ;
45+
46+ // 波纹震源 y 坐标(相对波纹区域坐标系,y=0 是档位名行)。
4147const RIPPLE_SOURCE_Y = 0 ;
4248
49+ /**
50+ * 根据终端宽度计算每档实际宽度(SEGMENT)。
51+ *
52+ * 规则:
53+ * - 留出 paddingX={1} 的左右各 1 列 → 可用宽度 = columns - 2
54+ * - 若可用宽度 <= MIN_SEGMENT * 6(72),用 MIN_SEGMENT(保持当前窄布局)
55+ * - 否则铺满:floor(可用宽度 / 6)
56+ *
57+ * 即"窄则不变,宽则铺满"。最小宽度保证 'ultracode' 9 字符能正常显示。
58+ */
59+ function computeSegment ( terminalColumns : number ) : number {
60+ const available = terminalColumns - 2 ; // paddingX={1} 两侧
61+ const minNeeded = MIN_SEGMENT * PANEL_POSITIONS . length ;
62+ if ( available <= minNeeded ) return MIN_SEGMENT ;
63+ return Math . floor ( available / PANEL_POSITIONS . length ) ;
64+ }
65+
66+ /**
67+ * 计算波纹震源 x 坐标(ultracode 段内 'ultracode' 标签的中心列)。
68+ *
69+ * 'ultracode' 是 9 字符,在 SEGMENT 列内居中:
70+ * offset = floor((SEGMENT - 9) / 2)
71+ * labelCenter = SEGMENT * 5 + offset + 4 (4 是 9 字符串的中心偏移)
72+ *
73+ * SEGMENT=12 → 60 + 1 + 4 = 65(与历史值一致)
74+ * SEGMENT=20 → 100 + 5 + 4 = 109
75+ */
76+ function computeRippleSourceX ( segment : number ) : number {
77+ const LABEL_LEN = 9 ; // 'ultracode'
78+ const offset = Math . max ( 0 , Math . floor ( ( segment - LABEL_LEN ) / 2 ) ) ;
79+ const labelCenter = Math . floor ( LABEL_LEN / 2 ) ; // 4
80+ return segment * ( PANEL_POSITIONS . length - 1 ) + offset + labelCenter ;
81+ }
82+
4383/**
4484 * 计算某段 idx 内居中文字的起始列。
45- * 'ultracode' = 9 字符 in SEGMENT=12 → offset = floor((12-9)/2) = 1 。
85+ * 动态 segment:textLen 字符在 segment 列内居中 。
4686 */
47- function segmentTextStartX ( idx : number , textLen : number ) : number {
48- return SEGMENT * idx + Math . max ( 0 , Math . floor ( ( SEGMENT - textLen ) / 2 ) ) ;
87+ function segmentTextStartX ( idx : number , textLen : number , segment : number ) : number {
88+ return segment * idx + Math . max ( 0 , Math . floor ( ( segment - textLen ) / 2 ) ) ;
4989}
5090
5191type Props = {
@@ -56,6 +96,13 @@ type Props = {
5696export function EffortPanel ( { appStateEffort, onDone } : Props ) : React . ReactNode {
5797 const setAppState = useSetAppState ( ) ;
5898 const model = useMainLoopModel ( ) ;
99+ const { columns } = useTerminalSize ( ) ;
100+
101+ // 自适应宽度:根据终端列数计算每档宽度。
102+ // 终端变化(resize)时 columns 改变 → 重新计算 → 重渲染。
103+ const segment = React . useMemo ( ( ) => computeSegment ( columns ) , [ columns ] ) ;
104+ const panelWidth = segment * PANEL_POSITIONS . length ;
105+ const rippleSourceX = React . useMemo ( ( ) => computeRippleSourceX ( segment ) , [ segment ] ) ;
59106
60107 const envOverride = getEffortEnvOverride ( ) ;
61108 const displayed = getDisplayedEffortLevel ( model , appStateEffort ) ;
@@ -64,8 +111,23 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
64111 const [ cursor , setCursor ] = React . useState < PanelPosition > ( initialCursor ) ;
65112 const [ done , setDone ] = React . useState ( false ) ;
66113
67- const rippleActive = cursor === 'ultracode' ;
68- const [ rippleRef , time ] = useRippleFrame ( rippleActive ) ;
114+ const isOnUltracode = cursor === 'ultracode' ;
115+ const [ fade , setFade ] = React . useState ( 0 ) ;
116+ // 仍在波纹模式:cursor 在 ultracode,或退出动画未结束(fade > 0)
117+ const showingRipple = isOnUltracode || fade > 0.001 ;
118+ const [ rippleRef , time ] = useRippleFrame ( showingRipple ) ;
119+
120+ // 淡入淡出驱动:每 tick(time 推进)朝目标步进 FADE_STEP。
121+ // 退出动画完成后 fade 归零,showingRipple 变 false,时钟停止订阅。
122+ React . useEffect ( ( ) => {
123+ if ( ! showingRipple ) return ;
124+ const target = isOnUltracode ? 1 : 0 ;
125+ setFade ( prev => {
126+ if ( prev === target ) return prev ;
127+ const next = target > prev ? prev + FADE_STEP : prev - FADE_STEP ;
128+ return target > prev ? Math . min ( target , next ) : Math . max ( target , next ) ;
129+ } ) ;
130+ } , [ time , isOnUltracode , showingRipple ] ) ;
69131
70132 const handleConfirm = React . useCallback ( ( ) => {
71133 if ( done ) return ;
@@ -102,32 +164,42 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
102164 const envRaw = process . env . CLAUDE_CODE_EFFORT_LEVEL ;
103165
104166 // 波纹行 cells 计算:返回该行所有 cell(含 overlay 文字)
167+ // fade 控制背景颜色亮度(0 → 全 transparent,1 → 完整波纹)。
168+ // 文字 overlay 也乘以 fade,让进入/退出动画整体淡入淡出。
105169 const renderRippleRow = React . useCallback (
106170 ( relY : number , overlays : Overlay [ ] ) : Segment [ ] => {
107171 const cells = computeRippleCells ( {
108172 y : relY + RIPPLE_SOURCE_Y ,
109- width : PANEL_WIDTH ,
173+ width : panelWidth ,
110174 time,
111- sourceX : RIPPLE_SOURCE_X ,
175+ sourceX : rippleSourceX ,
112176 sourceY : RIPPLE_SOURCE_Y ,
113177 } ) ;
114178 const overlayed = applyOverlaysToCells ( cells , overlays ) ;
115- return cellsToSegments ( overlayed ) ;
179+ const faded = fadeCells ( overlayed , fade ) ;
180+ return cellsToSegments ( faded ) ;
116181 } ,
117- [ time ] ,
182+ [ time , fade , panelWidth , rippleSourceX ] ,
118183 ) ;
119184
120185 return (
121- < Box ref = { rippleRef } flexDirection = "column" paddingX = { 1 } width = { PANEL_WIDTH + 2 } >
186+ < Box ref = { rippleRef } flexDirection = "column" paddingX = { 1 } width = { panelWidth + 2 } >
122187 < Text bold color = "suggestion" >
123188 Effort
124189 </ Text >
125190 { envActive && < Text color = "warning" > { `⚠ CLAUDE_CODE_EFFORT_LEVEL=${ envRaw } overrides this session` } </ Text > }
126- { rippleActive ? (
127- < RippleContent renderRow = { renderRippleRow } cursor = { cursor } />
191+ { showingRipple ? (
192+ < RippleContent
193+ renderRow = { renderRippleRow }
194+ cursor = { cursor }
195+ fade = { fade }
196+ segment = { segment }
197+ panelWidth = { panelWidth }
198+ time = { time }
199+ />
128200 ) : (
129201 < >
130- < PlainContent cursor = { cursor } />
202+ < PlainContent cursor = { cursor } segment = { segment } panelWidth = { panelWidth } />
131203 < Box marginTop = { 1 } >
132204 < Text color = "subtle" > ←/→ adjust · Enter confirm · Esc cancel</ Text >
133205 </ Box >
@@ -139,17 +211,25 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
139211
140212// ---- 普通模式(无波纹)----
141213
142- function PlainContent ( { cursor } : { cursor : PanelPosition } ) : React . ReactNode {
214+ function PlainContent ( {
215+ cursor,
216+ segment,
217+ panelWidth,
218+ } : {
219+ cursor : PanelPosition ;
220+ segment : number ;
221+ panelWidth : number ;
222+ } ) : React . ReactNode {
143223 return (
144224 < >
145225 < Box marginTop = { 1 } flexDirection = "row" justifyContent = "space-between" >
146226 < Text color = "suggestion" > Faster</ Text >
147227 < Text color = "suggestion" > Smarter</ Text >
148228 </ Box >
149- < Text color = "subtle" > { '─' . repeat ( PANEL_WIDTH ) } </ Text >
229+ < Text color = "subtle" > { '─' . repeat ( panelWidth ) } </ Text >
150230 < Box flexDirection = "row" >
151231 { PANEL_POSITIONS . map ( p => (
152- < Box key = { `cursor-${ p } ` } width = { SEGMENT } justifyContent = "center" >
232+ < Box key = { `cursor-${ p } ` } width = { segment } justifyContent = "center" >
153233 < Text bold color = { cursor === p ? 'suggestion' : 'subtle' } >
154234 { cursor === p ? '▲' : ' ' }
155235 </ Text >
@@ -158,16 +238,16 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode {
158238 </ Box >
159239 < Box flexDirection = "row" >
160240 { PANEL_POSITIONS . map ( p => (
161- < Box key = { `label-${ p } ` } width = { SEGMENT } justifyContent = "center" >
241+ < Box key = { `label-${ p } ` } width = { segment } justifyContent = "center" >
162242 < Text bold = { cursor === p } color = { cursor === p ? 'suggestion' : 'subtle' } >
163243 { p }
164244 </ Text >
165245 </ Box >
166246 ) ) }
167247 </ Box >
168248 < Box flexDirection = "row" >
169- < Box width = { SEGMENT * ( PANEL_POSITIONS . length - 1 ) } />
170- < Box width = { SEGMENT } justifyContent = "center" >
249+ < Box width = { segment * ( PANEL_POSITIONS . length - 1 ) } />
250+ < Box width = { segment } justifyContent = "center" >
171251 < Text color = "subtle" > { SUBLABEL_ULTRACODE } </ Text >
172252 </ Box >
173253 </ Box >
@@ -193,58 +273,73 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode {
193273type RippleContentProps = {
194274 renderRow : ( relY : number , overlays : Overlay [ ] ) => Segment [ ] ;
195275 cursor : PanelPosition ;
276+ fade : number ;
277+ segment : number ;
278+ panelWidth : number ;
279+ time : number ;
196280} ;
197281
198- function RippleContent ( { renderRow, cursor } : RippleContentProps ) : React . ReactNode {
199- const cursorIdx = PANEL_POSITIONS . indexOf ( 'ultracode' ) ;
282+ function RippleContent ( { renderRow, cursor, segment, panelWidth, time } : RippleContentProps ) : React . ReactNode {
283+ // 光标索引跟随 cursor(退出动画期间 cursor 已移到别处,
284+ // 让 ▲ overlay 跟着移走,ultracode 段恢复普通背景色)。
285+ const cursorIdx = PANEL_POSITIONS . indexOf ( cursor ) ;
286+ // 副标签固定在 ultracode 段下方,不跟随光标移动。
287+ const ultracodeIdx = PANEL_POSITIONS . length - 1 ;
288+
289+ // 文字颜色跟随波浪色相旋转:取当前 time 的 hueShift,
290+ // 应用到所有 overlay 颜色,让文字与背景色环保持同步。
291+ const hueShift = getHueShiftAtTime ( time ) ;
292+ const overlayColor = rotateHue ( COLOR_OVERLAY , hueShift ) ;
293+ const labelSelectedColor = rotateHue ( COLOR_LABEL_SELECTED , hueShift ) ;
294+ const labelDefaultColor = rotateHue ( COLOR_LABEL_DEFAULT , hueShift ) ;
200295
201- const fasterOverlay : Overlay = { text : 'Faster' , x : 0 , color : COLOR_OVERLAY } ;
296+ const fasterOverlay : Overlay = { text : 'Faster' , x : 0 , color : overlayColor } ;
202297 const smarterOverlay : Overlay = {
203298 text : 'Smarter' ,
204- x : PANEL_WIDTH - 'Smarter' . length ,
205- color : COLOR_OVERLAY ,
299+ x : panelWidth - 'Smarter' . length ,
300+ color : overlayColor ,
206301 } ;
207302 const separatorOverlay : Overlay = {
208- text : '─' . repeat ( PANEL_WIDTH ) ,
303+ text : '─' . repeat ( panelWidth ) ,
209304 x : 0 ,
210- color : COLOR_LABEL_DEFAULT ,
305+ color : labelDefaultColor ,
211306 } ;
212307 const cursorOverlay : Overlay = {
213308 text : '▲' ,
214- x : segmentTextStartX ( cursorIdx , 1 ) ,
215- color : COLOR_OVERLAY ,
309+ x : segmentTextStartX ( cursorIdx , 1 , segment ) ,
310+ color : overlayColor ,
216311 } ;
217312 const labelOverlays : Overlay [ ] = PANEL_POSITIONS . map ( ( p , idx ) => ( {
218313 text : p ,
219- x : segmentTextStartX ( idx , p . length ) ,
220- color : p === cursor ? COLOR_LABEL_SELECTED : COLOR_LABEL_DEFAULT ,
314+ x : segmentTextStartX ( idx , p . length , segment ) ,
315+ color : p === cursor ? labelSelectedColor : labelDefaultColor ,
221316 } ) ) ;
222317 const sublabelOverlay : Overlay = {
223318 text : SUBLABEL_ULTRACODE ,
224- x : segmentTextStartX ( cursorIdx , SUBLABEL_ULTRACODE . length ) ,
225- color : COLOR_LABEL_DEFAULT ,
226- } ;
227- const hintOverlay : Overlay = {
228- text : '←/→ adjust · Enter confirm · Esc cancel' ,
229- x : 0 ,
230- color : COLOR_LABEL_DEFAULT ,
319+ x : segmentTextStartX ( ultracodeIdx , SUBLABEL_ULTRACODE . length , segment ) ,
320+ color : labelDefaultColor ,
231321 } ;
232322
233323 // 各行 y 坐标(相对震源 RIPPLE_SOURCE_Y = 档位名行)
324+ // y=-4: 顶部纯波纹行(视觉一致,无 overlay)
234325 // y=-3: Faster/Smarter
235326 // y=-2: 分隔线
236327 // y=-1: ▲
237328 // y=0: 档位名(震源)
238329 // y=1: 副标签
239- // y=2: 快捷键行(y 方向延伸覆盖到底部)
330+ // y=2: 底部纯波纹行(视觉一致,无 overlay)
331+ //
332+ // 快捷键行:plain Text,不参与波纹渲染(无背景动画),紧贴底部波纹行。
240333 return (
241334 < >
335+ < RippleRow segments = { renderRow ( - 4 , [ ] ) } />
242336 < RippleRow segments = { renderRow ( - 3 , [ fasterOverlay , smarterOverlay ] ) } />
243337 < RippleRow segments = { renderRow ( - 2 , [ separatorOverlay ] ) } />
244338 < RippleRow segments = { renderRow ( - 1 , [ cursorOverlay ] ) } />
245339 < RippleRow segments = { renderRow ( 0 , labelOverlays ) } />
246340 < RippleRow segments = { renderRow ( 1 , [ sublabelOverlay ] ) } />
247- < RippleRow segments = { renderRow ( 2 , [ hintOverlay ] ) } />
341+ < RippleRow segments = { renderRow ( 2 , [ ] ) } />
342+ < Text color = { COLOR_LABEL_DEFAULT } > ←/→ adjust · Enter confirm · Esc cancel</ Text >
248343 </ >
249344 ) ;
250345}
0 commit comments